Skip to content

fix(statistical): MAX/MIN/MAXA/MINA — an empty argument is an error, a numberless one is zero - #777

Merged
hhimanshu merged 4 commits into
mainfrom
fix/771-min-empty-array
Jul 28, 2026
Merged

fix(statistical): MAX/MIN/MAXA/MINA — an empty argument is an error, a numberless one is zero#777
hhimanshu merged 4 commits into
mainfrom
fix/771-min-empty-array

Conversation

@hhimanshu

@hhimanshu hhimanshu commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

closes #771

Summary

MIN returned 0 for an empty array where MAX and Google Sheets both return #REF!. Probing that turned it into a four-function problem with a rule none of them fully implemented.

"No numbers" is two conditions, not one:

{} {"a","b"}
Sheets #REF! 0
MAX before #REF! #REF!
MIN before 0 0
MAXA/MINA before #N/A 0

An empty argument is an error. A populated argument that happens to hold nothing numeric is 0. MAX collapsed both into one check and was wrong in one direction; the other three skipped the check and were wrong in the other.

Booleans do not coerce inside an array, so {TRUE,FALSE} is numberless the same way {"a","b"} is — captured, and easy to miss.

The review caught this PR inverting its own premise

The first version marked "this array had content" with a catch-all _ => had_content = true. That caught text and booleans — intended — and also Value::Date:

=MAX(<a column of dates>)    main: #REF!    first version: 0

recalc.rs maps a workbook date cell to CoreValue::Date, so that is the everyday form. #771 exists because a silent 0 is more dangerous than a visible error, and the fix would have manufactured one on a different input — in the one shape no fixture covers.

Now explicit:

Value::Text(_) | Value::Bool(_) => *had_content = true,
_ => {}

Carves out exactly what the oracle backs, and stops every future Value variant inheriting content-hood by accident. Pinned by max_array_of_only_dates_is_unchanged_at_ref_error.

That defect exposed a real pre-existing one — MAX/MIN ignore dates in arrays entirely, so =MIN(A1:A10) over a date column has always returned a silent zero. Filed as #776, deliberately not fixed here.

Differential — 20,128 cases

Two builds, this branch versus a clean export of origin/main, machine-checked assertions rather than eyeballing:

category cases changed
date-only argument 920 0
blank-only argument 720 0
empty array 1,726 → #REF!
numberless populated 351 #REF!0 (MAX only)

MAX moved only #REF!0, never the reverse. Nothing changed without a triggering argument. Fourteen other reducers — SUM, AVERAGE, COUNT, PRODUCT, MEDIAN, STDEV and friends — byte-identical.

Blank-only arrays are deliberately untouched. The four disagree three ways there (MAX #REF!, MIN 0, MAXA/MINA #N/A); it is unprobed and tracked as #775. An earlier attempt to probe it produced a harness artifact — the sheet reference did not resolve, so =MAX(Data!M4) returned #REF! with M4 holding 1 — and that evidence was withdrawn rather than shipped. Nested-empty ({{}}) is left for the same reason and by the same argument: it is the same question, and one capture settles both.

⚠️ Most of this merges on evidence not yet in this repository

Only =MAX({})#REF! has an in-repo fixture row. =MIN({}), =MAXA({}), =MINA({}), =MAX({"a","b"}), =MAX({TRUE,FALSE}) and the text-array rows for the other three are captured only in the conformance-fixtures pipeline, not yet exported here.

A reviewer working from this repo alone cannot verify them. The ordering is forced — those rows fail until this code exists — so they land in a fixtures-only PR immediately after, which will also relocate bugs.tsv row 2239 (=MIN(SPARKLINE({1,2,3}),{})#REF!, passing as of this branch).

How to test

cargo test -p truecalc-core --test conformance
cargo test -p truecalc-core statistical::max
cargo test -p truecalc-core statistical::min

Directly:

=MIN({})            #REF!   (was 0)
=MAX({"a","b"})     0       (was #REF!)
=MAX({TRUE,FALSE})  0       (was #REF!)
=MAXA({}) =MINA({}) #REF!   (was #N/A)
=MAX({DATE(2020,1,1)})  #REF!   unchanged — see #776

Review

  • Independent review: 1 blocker + 6 findings, all fixed. The blocker was the date regression above. Also caught: a self-confirmed assertion added to a file whose header says nothing in it is self-confirmed; a comment claiming fixture backing that did not exist; and a dead flag write whose doc comment described an effect that could not occur. The reviewer built its own differential rather than accepting the author's, and independently confirmed blank-only preservation across six materializations.

Test plan

  • cargo test --workspace — 3,751 tests, 0 failures
  • cargo clippy --workspace -- -D warnings — clean
  • cargo nextest run --workspace --profile ci — exit 0
  • bugs.tsv failing-row sets diffed, not counts — one row flipped to passing, zero regressed
  • Both mod.rs files rustfmt-clean (main is dirty repo-wide; no new violations added)
  • CI green

Related

🤖 Generated with Claude Code


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

`=MIN({})` answered 0 while `=MAX({})` answered #REF!, so the two
reducers disagreed about whether the same empty input is an error. A
plausible-looking 0 propagates silently into whatever consumes it; an
error is visible.

`min_fn` now returns #REF! when an argument is an empty array, the same
rule `max_fn` already carries.

The rule is deliberately narrower than MAX's. `max_fn` errors whenever it
saw an array and found no numbers at all; MIN cannot, because the
fixtures pin `=IFERROR(MIN({"a","b","c"}),"no numbers")` to the number 0.
So a *populated* array holding nothing numeric still answers 0, and only
a genuinely empty one is #REF!.

Verified with a differential over 14,200 cases (MIN, MAX, MINA, MAXA
across scalars, numeric/text/boolean/mixed/blank arrays, empty strings,
numeric text, nested and 2-D shapes, leading and trailing errors, zoned
instants, sparklines, and range- and name-delivered forms through a
seeded resolver, as 1-, 2- and 3-argument calls). 554 cases changed;
every one is a MIN call carrying an empty-array argument and every one
now returns #REF!. MAX, MINA and MAXA are byte-identical.

The `=MIN(SPARKLINE({1,2,3}),{})` row in bugs.tsv now passes (2055 →
2056 passing, 182 → 181 open). It is left in place: relocating it to its
category TSV is a fixtures-only change and cannot ride in a commit that
also touches code.

closes #771
…d MAXA

Google Sheets was asked directly about all four reducers, and the rule is
uniform:

                {}        {"a","b"}    blank range
  MAX           #REF!     0            #REF!
  MIN           #REF!     0            #REF!
  MAXA          #REF!     0            —
  MINA          #REF!     0            —

So "no numbers" is two conditions, not one. An *absent* argument is
#REF!; a *populated* argument that happens to hold nothing numeric is 0.
A range of blank cells sides with the empty array rather than the text
array — blanks are absent, not present-and-unusable.

MAX collapsed both into a single `had_array && result.is_none()` check
and so answered #REF! for `=MAX({"a","b"})` and `=MAX({TRUE,FALSE})`,
which are 0. It now also tracks whether an array held anything other than
a blank, and errors only when it did not — leaving `=MAX(<blank range>)`
at #REF! where it already belonged.

MIN gained the blank-array half of the same rule, which its previous
empty-array-only fix did not cover.

MINA and MAXA needed the empty-array check alone. They reach the other
answers by a different mechanism — text folds in as 0 rather than being
skipped, so `=MAXA({"a","b",5,3})` is 5 and `=MAXA({"a","b",-5,-3})` is
0 — and a populated array is therefore never numberless for them. Their
agreement with MAX on the two probed rows is a coincidence of values, not
shared semantics, so they keep their own fold helpers.

The blank-range answer for MINA and MAXA has no captured row, so both
keep today's #N/A there rather than being moved to match their non-A
counterparts on a guess.

Verified with a differential over 16,320 cases across all four functions
(scalars, numeric/text/boolean/blank/mixed arrays, empty strings, numeric
text, nested and 2-D shapes, flat and nested-row blank ranges, leading
and trailing errors, zoned instants, sparklines, and range- and
name-delivered forms through a seeded resolver, as 1-, 2- and 3-argument
calls). 1,879 cases changed:

  MIN   624  → #REF!, every one carrying an absent or numberless argument
  MAX   199  #REF! → 0, every one carrying a numberless argument, and no
             case whose arguments are all absent moved at all
  MINA  528  → #REF!, every one carrying an empty array
  MAXA  528  → #REF!, every one carrying an empty array

No case changed in any other direction, and none changed without a
triggering argument. Every captured row above is reproduced, including
the two that must not move: `=MAX(<blank range>)` stays #REF! and
`=MAX(SPARKLINE({1,2,3}),{})` stays #REF!.

bugs.tsv is unchanged at 2056 passing / 181 open — the same single row
that this branch already flipped, with no row regressing.

The zone-aware short-circuit is untouched: `=MIN(TZDATETIME(...),{})`
still returns the instant rather than #REF!, because `zoned_extreme` runs
before the argument loop. No captured row covers it.
… one

The blank-range evidence behind the previous commit was a probe artifact,
not a Sheets answer. Controls added alongside it show the harness never
resolved `Data!` ranges for these functions at all:

  =COUNTA(Data!M1:M4)   1        the sheet exists and M4 holds 1
  =MAX(Data!M4)         #REF!    but MAX cannot see it
  =MAX(Data!M1:M3)      #REF!    so this was never about blanks

MAX over a single cell holding 1 cannot really be #REF!, so every `Data!`
row was measuring the harness. Those rows have been withdrawn from the
capture, which now contains only array literals — which need no setup:

  {}             #REF!   MAX, MIN, MAXA, MINA
  {"a","b"}      0       all four
  {TRUE,FALSE}   0       MAX

Both oracle-backed halves stay. What goes is the claim about blanks.

MIN loses the `array_had_content` rule entirely; its empty-array check is
enough, because a populated-but-numberless array already answered 0. An
all-blank array is back to 0.

MAX keeps `array_had_content`, but only as the thing that carves text and
booleans *out* of the old blanket rule. An all-blank array sets no
content and so keeps the #REF! MAX has always given it. That is not a
claim about blanks — it is the absence of one.

MINA and MAXA were never touched on this axis and stay at #N/A.

Blank-only arrays are therefore unprobed for all four, and the four do
not agree with each other:

  MAX #REF!    MIN 0    MAXA #N/A    MINA #N/A

That disagreement predates this branch and is left exactly as found. It
needs a capture, not a guess.

Differential re-run over 16,320 cases against origin/main. 1,829 changed:

  MIN   574  → #REF!, every one carrying an empty array
  MAX   199  #REF! → 0, every one carrying a numberless array
  MINA  528  → #REF!, every one carrying an empty array
  MAXA  528  → #REF!, every one carrying an empty array

Machine-checked assertions, all clean. The one that matters most here:
688 cases have a blank-only array as their only array argument, and
**zero** of them changed. Blank-only shapes were probed flat, as nested
one-element rows, and via a defined name, for all four functions; every
one is byte-identical to main.

bugs.tsv unchanged at 2056 passing / 181 open, with the failing-row sets
diffed rather than the counts compared: one row flipped to passing, none
regressed.
Review blocker. `array_had_content` was set by a catch-all `_ => true`, so
every non-numeric variant inherited content-hood — including `Value::Date`,
which `max_array_into` does not fold into a result.

  =MAX({DATE(2020,1,1),DATE(2021,1,1)})   main #REF!   branch 0
  =MAX(<a column of dates>)               main #REF!   branch 0

A workbook date cell maps to `Value::Date`, so `=MAX(A1:A10)` over a date
column is the everyday form of this. Both answers are wrong against
Sheets, which returns the latest date — but turning a visible error into a
plausible-looking 0 is precisely the hazard this branch exists to remove,
and it did so in the one shape no fixture covers.

The flag now matches `Value::Text(_) | Value::Bool(_)` explicitly, which is
exactly what the capture backs. Everything else numberless — dates, zoned
instants, and any variant added later — keeps whatever main answered. A
regression test pins the date case so it cannot drift again.

Also in this commit, all from the same review:

- Dropped the dead `had_content` write in the `Sparkline` arm. The
  `skipped_sparkline` early return always precedes the `had_content` test,
  so the write could never be read; the doc comment described an effect
  that did not exist. With it gone the sparkline flag is load-bearing
  again, as originally designed.
- Removed a self-confirmed assertion from tests/sparkline.rs, whose header
  promises every expectation is a captured row. `=MIN(SPARKLINE({1,2,3}),
  {"a"})` was not one. The remaining MIN assertion there cites its
  bugs.tsv row.
- Corrected min/mod.rs, which claimed the fixtures pin `=MIN({"a","b"})`.
  They do not — only the IFERROR row exists in-repo. The doc now says which
  evidence is in this repo and which lands separately.
- Formatted the five new assertions rustfmt objected to. Pre-existing
  violations left alone.

Nested empty arrays (`{{}}`) are left as found: MAX #REF!, MIN 0, MAXA and
MINA #N/A. Unreachable from a resolver-delivered range, unprobed for all
four, and the same open question as blank-only arrays — one capture would
settle both. Making three of them agree with MAX on a guess is the move
that produced the original defect.

The date behaviour is filed separately as #776 rather than fixed here; it
needs an oracle this repo does not have.

Differential re-run over 20,128 cases against origin/main, now with date
atoms in six materializations. 2,077 changed:

  MIN   606  → #REF!, every one carrying an empty array
  MAX   351  #REF! → 0, every one carrying a numberless array
  MINA  560  → #REF!, every one carrying an empty array
  MAXA  560  → #REF!, every one carrying an empty array

Machine-checked, no violations:

  date-only arguments    920 cases, 0 changed
  blank-only arguments   720 cases, 0 changed

All six date shapes — inline literal, cell range, named array, nested
rows, date beside a blank, date beside a number — are identical to main
for all four functions. Every oracle row still holds.
@hhimanshu hhimanshu self-assigned this Jul 28, 2026
@github-actions

github-actions Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Test Coverage by Category

Category Unit Tests Google Sheets Conformance Property Cases Total
Array 42 547/547 ✓ 1,000 (2×500) 1,589
Database 35 182/182 ✓ 3,500 (7×500) 3,717
Date 369 418/418 ✓ 2,500 (5×500) 3,287
Engineering 245 886/888 ⚠ 5,500 (11×500) 6,633
Filter 11 80/80 ✓ 4,500 (9×500) 4,591
Financial 149 1,208/1,208 ✓ 2,000 (4×500) 3,357
Info 0 256/256 ✓ 4,500 (9×500) 4,756
Logical 121 263/263 ✓ 3,500 (7×500) 3,884
Lookup 69 392/392 ✓ 1,000 (2×500) 1,461
Math 536 2,006/2,006 ✓ 8,000 (16×500) 10,542
Operator 87 250/250 ✓ 7,500 (15×500) 7,837
Parser 83 92/92 ✓ 4,000 (8×500) 4,175
Query 37 37
Statistical 496 3,156/3,156 ✓ 5,000 (10×500) 8,652
Text 298 729/733 ⚠ 4,000 (8×500) 5,031
Timezone 47 47
Volatile 0 3,500 (7×500) 3,500
Web 29 56/56 ✓ 6,000 (12×500) 6,085
Total 2,910 10,521/10,527 66,000 (132×500) ~79,437

✓ = 100% passing · ⚠ = known deviation · The ~79,437 total counts formula evaluations (each conformance row and each property case = 1). GitHub Checks reports 3,745 Rust test functions: 2,910 unit + 159 property functions (shown as cases above) + 676 conformance/integration.

@hhimanshu
hhimanshu merged commit abc8469 into main Jul 28, 2026
8 checks passed
@hhimanshu
hhimanshu deleted the fix/771-min-empty-array branch July 28, 2026 06:02
@github-actions github-actions Bot locked and limited conversation to collaborators Jul 28, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(statistical): MIN returns 0 for an empty array where Sheets returns #REF!, diverging from MAX

1 participant